contacts_showtips_show и tips_click, другие — только tips_show. Проверить гипотезу: конверсия в просмотры контактов различается у этих двух группОписание проекта
Отделу аналитики приложения "Ненужные вещи" поступила задача от продукт-менеджера. Необходимо понять аудиторию приложения, сегментировать пользователей, для этого нам предстоит пронализировать поведение пользователей,- мы должны понять аудиторию, понять сценарии использования приложения
Цель исследования
В наличии есть следующие данные
mobile_dataset.csv - датасет содержит данные о событиях, совершенных в мобильном приложении "Ненужные вещи". В нем пользователи продают свои ненужные вещи, размещая их на доске объявлений. В датасете содержатся данные пользователей, впервые совершивших действия в приложении после 7 октября 2019 годаmobile_sources.csv - датасет с источниками, с которых пользователь установил приложениеЗадачи исследования
Ход исследования
import pandas as pd
import datetime
import numpy as np
from scipy import stats as st
from matplotlib import pyplot as plt
import seaborn as sns
import plotly.express as px
from plotly import graph_objects as go
from plotly.subplots import make_subplots
import plotly.io as pio
# определим свою цветовую гамму для графиков проекта
bam_color = ['#3C6255','#9E6F21', '#A4D0A4'] #, '#EAE7B1', '#CCD5AE', '#617A55', '#FEE8B0', '#B8E7E1'] # палитра цветов
pio.templates['bam'] = go.layout.Template(layout_colorway=bam_color)
pio.templates.default = "plotly_white+bam"
Загрузим данные о событиях, совершенных в мобильном приложении из CSV-файла в переменные
logs = pd.read_csv('mobile_dataset.csv') # логи
sources = pd.read_csv('mobile_sourсes.csv') # источники
display(logs.head())
sources.head()
| event.time | event.name | user.id | |
|---|---|---|---|
| 0 | 2019-10-07 00:00:00.431357 | advert_open | 020292ab-89bc-4156-9acf-68bc2783f894 |
| 1 | 2019-10-07 00:00:01.236320 | tips_show | 020292ab-89bc-4156-9acf-68bc2783f894 |
| 2 | 2019-10-07 00:00:02.245341 | tips_show | cf7eda61-9349-469f-ac27-e5b6f5ec475c |
| 3 | 2019-10-07 00:00:07.039334 | tips_show | 020292ab-89bc-4156-9acf-68bc2783f894 |
| 4 | 2019-10-07 00:00:56.319813 | advert_open | cf7eda61-9349-469f-ac27-e5b6f5ec475c |
| userId | source | |
|---|---|---|
| 0 | 020292ab-89bc-4156-9acf-68bc2783f894 | other |
| 1 | cf7eda61-9349-469f-ac27-e5b6f5ec475c | yandex |
| 2 | 8c356c42-3ba9-4cb6-80b8-3f868d0192c3 | yandex |
| 3 | d9b06b47-0f36-419b-bbb0-3533e582a6cb | other |
| 4 | f32e1e2a-3027-4693-b793-b7b3ff274439 |
Отобразим основную информацию о данных
display(logs.info())
sources.info()
<class 'pandas.core.frame.DataFrame'> RangeIndex: 74197 entries, 0 to 74196 Data columns (total 3 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 event.time 74197 non-null object 1 event.name 74197 non-null object 2 user.id 74197 non-null object dtypes: object(3) memory usage: 1.7+ MB
None
<class 'pandas.core.frame.DataFrame'> RangeIndex: 4293 entries, 0 to 4292 Data columns (total 2 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 userId 4293 non-null object 1 source 4293 non-null object dtypes: object(2) memory usage: 67.2+ KB
Отобразили начальные строки таблицы и общую информацию
Описание данных
В нашем распоряжении два датасета. Файл mobile_dataset.csv хранит информацию о логах, где каждая запись это действие пользователя, или событие, mobile_sourсes.csv - источники, с которых пользователь установил приложение
Структура mobile_dataset.csv:
event.time — время совершения,user.id — идентификатор пользователя,event.name — действие пользователя.Виды действий:
advert_open — открыл карточки объявления,photos_show — просмотрел фотографий в объявлении,tips_show — увидел рекомендованные объявления,tips_click — кликнул по рекомендованному объявлению,contacts_show и show_contacts — посмотрел номер телефона,contacts_call — позвонил по номеру из объявления,map — открыл карту объявлений,search_1—search_7 — разные действия, связанные с поиском по сайту,favorites_add — добавил объявление в избранное.Структура mobile_sourсes.csv:
userId — идентификатор пользователя,source — источник, с которого пользователь установил приложение.Приведем к нижнему регистру и к стилю: слова_разделены_подчёркиванием
logs.columns = ['event_time', 'event_name', 'user_id']
sources.columns = ['user_id', 'source']
В столбце logs['event_time'] переведем значения в специальный формат для работы с датами
logs['event_time'] = pd.to_datetime(logs['event_time'])
Определим количество пропущенных значений в таблице для каждого столбца
display(logs.isna().sum())
sources.isna().sum()
event_time 0 event_name 0 user_id 0 dtype: int64
user_id 0 source 0 dtype: int64
Пропусков не обнаружено
Выявим явные дубликаты и удалим, если таковые имеются
for table in [logs, sources]:
display(table.duplicated().sum())
0
0
Выявим неявные дубликаты
Посмотрим какие действия совершают пользователи
logs['event_name'].unique()
array(['advert_open', 'tips_show', 'map', 'contacts_show', 'search_4',
'search_5', 'tips_click', 'photos_show', 'search_1', 'search_2',
'search_3', 'favorites_add', 'contacts_call', 'search_6',
'search_7', 'show_contacts'], dtype=object)
Заменим show_contacts на contacts_show - это одно и тоже действие
logs['event_name'] = logs['event_name'].replace('show_contacts', 'contacts_show')
Заменим все варианты search_N на search - это все действия поиска, разделение в нашем исследование не нужно
search = ['search_'+str(i) for i in range(1,8)]
logs['event_name'] = logs['event_name'].replace(search, 'search')
Проверим какие бывают источники привлечения пользователей
sources['source'].unique()
array(['other', 'yandex', 'google'], dtype=object)
Тут все в порядке: 3 источника yandex, google, other
Такие значения могут быть в столбце event_time
fig = px.box(logs, y="event_time")
fig.update_layout(
title={
'text': "Распределение даты и времени событий в логе",
'y':0.9,
'x':0.5,
'xanchor': 'center',
'yanchor': 'top'},
yaxis_title="дата и время события",
)
fig.show()
logs = logs.merge(sources, on='user_id', how='left')
logs.head()
| event_time | event_name | user_id | source | |
|---|---|---|---|---|
| 0 | 2019-10-07 00:00:00.431357 | advert_open | 020292ab-89bc-4156-9acf-68bc2783f894 | other |
| 1 | 2019-10-07 00:00:01.236320 | tips_show | 020292ab-89bc-4156-9acf-68bc2783f894 | other |
| 2 | 2019-10-07 00:00:02.245341 | tips_show | cf7eda61-9349-469f-ac27-e5b6f5ec475c | yandex |
| 3 | 2019-10-07 00:00:07.039334 | tips_show | 020292ab-89bc-4156-9acf-68bc2783f894 | other |
| 4 | 2019-10-07 00:00:56.319813 | advert_open | cf7eda61-9349-469f-ac27-e5b6f5ec475c | yandex |
logs['event_dt'] = logs['event_time'].dt.date
logs = logs.sort_values(by=['user_id', 'event_time']).reset_index(drop=True)
display(logs.head())
logs.info()
| event_time | event_name | user_id | source | event_dt | |
|---|---|---|---|---|---|
| 0 | 2019-10-07 13:39:45.989359 | tips_show | 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 | other | 2019-10-07 |
| 1 | 2019-10-07 13:40:31.052909 | tips_show | 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 | other | 2019-10-07 |
| 2 | 2019-10-07 13:41:05.722489 | tips_show | 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 | other | 2019-10-07 |
| 3 | 2019-10-07 13:43:20.735461 | tips_show | 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 | other | 2019-10-07 |
| 4 | 2019-10-07 13:45:30.917502 | tips_show | 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 | other | 2019-10-07 |
<class 'pandas.core.frame.DataFrame'> RangeIndex: 74197 entries, 0 to 74196 Data columns (total 5 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 event_time 74197 non-null datetime64[ns] 1 event_name 74197 non-null object 2 user_id 74197 non-null object 3 source 74197 non-null object 4 event_dt 74197 non-null object dtypes: datetime64[ns](1), object(4) memory usage: 2.8+ MB
заменили названия столбцов
слова_разделены_подчёркиваниемпреобразовали данные в нужные типы
logs['event_time'] перевели значения в специальный формат для работы с датамиобработали пропуски
обработали дубликаты
show_contacts на contacts_show - это одно и тоже действиеsearch_N на search - это все действия поиска, разделение в нашем исследование не нужноyandex, google, otherобработали редкие и выбивающиеся значения
event_timeboxplot - на нем не наблюдаем каких либо выбросов, оставим данные без измененийобъединили две таблицы в одну
mobile_dataset.csv и mobile_sourсes.csvдобавим столбец с датой
event_dt соответсвующей дате события event_timeотсортировали датафрейм по пользователям и времени события, это пригодится в дальнейшем исследовании
оценили общую информацию по таблице после преобразований
logs:event_time — время события;event_name — название события;user_id — уникальный идентификатор пользователя;source — источник привлечения пользователя в приложениеevent_dt - дата событияprint('событий в логе:', len(logs))
событий в логе: 74197
Всего 74197 событий в логе
print('всего пользователей:', logs['user_id'].nunique())
всего пользователей: 4293
Всего 4293 пользователь в логе
logs.groupby('user_id')['event_name'].count().reset_index()['event_name'].describe().reset_index()
| index | event_name | |
|---|---|---|
| 0 | count | 4293.000000 |
| 1 | mean | 17.283252 |
| 2 | std | 29.130677 |
| 3 | min | 1.000000 |
| 4 | 25% | 5.000000 |
| 5 | 50% | 9.000000 |
| 6 | 75% | 17.000000 |
| 7 | max | 478.000000 |
В среднем на пользователя приходится 17 событий
Медианное количество событий на пользователя 9
Найдем максимальную и минимальную дату
print('Максимальная дата:', logs['event_dt'].max())
print('Минимальная дата:', logs['event_dt'].min())
Максимальная дата: 2019-11-03 Минимальная дата: 2019-10-07
Минимальная и максимальная даты привлечения пользователей 2019-10-07 и 2019-11-03, соотвествуют датам из ТЗ
plt.figure(figsize=(10, 5))
sns.histplot(logs['event_dt'])
plt.grid()
plt.xlabel('дата события', fontsize=15, color='blue')
plt.ylabel('количество событий (шт.)', fontsize=15, color='blue')
plt.xticks(ticks=logs['event_dt'].unique(), rotation=90, fontsize=10)
plt.title('Количество событий в день', fontsize=15);
Данные за весь период с 2019-10-07 по 2019-11-03 однородны по наполненности, будем использовать для анализа данные за весь период
Выделим сессии пользователя, примем одну сессию равной календарному дню. Данная информация понадобить для исследования воронки событий
session = (logs.groupby(['user_id', 'event_dt']).agg(
{
'event_name': 'unique',
'event_time': ['min', 'max']
}
)
.reset_index()
)
session['event_name'] = session['event_name'].astype('string')
session = session.drop_duplicates().reset_index(drop=True)
session.columns = ['user_id', 'session_dt', 'script', 'session_start', 'session_end']
session['session_duration'] = (((session['session_end'] - session['session_start']).astype('timedelta64[s]')) / 60).round(1)
session['session_week'] = session['session_dt'].dt.isocalendar().week
session.head()
| user_id | session_dt | script | session_start | session_end | session_duration | session_week | |
|---|---|---|---|---|---|---|---|
| 0 | 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 | 2019-10-07 | ['tips_show'] | 2019-10-07 13:39:45.989359 | 2019-10-07 13:49:41.716617 | 9.9 | 41 |
| 1 | 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 | 2019-10-09 | ['map' 'tips_show'] | 2019-10-09 18:33:55.577963 | 2019-10-09 18:42:22.963948 | 8.4 | 41 |
| 2 | 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 | 2019-10-21 | ['tips_show' 'map'] | 2019-10-21 19:52:30.778932 | 2019-10-21 20:07:30.051028 | 15.0 | 43 |
| 3 | 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 | 2019-10-22 | ['map' 'tips_show'] | 2019-10-22 11:18:14.635436 | 2019-10-22 11:30:52.807203 | 12.6 | 43 |
| 4 | 00157779-810c-4498-9e05-a1e9e3cedf93 | 2019-10-19 | ['search' 'photos_show'] | 2019-10-19 21:34:33.849769 | 2019-10-19 21:59:54.637098 | 25.3 | 42 |
profiles = (
logs.groupby('user_id').agg(
{
'source': 'first',
'event_name': ['count', 'unique'],
'event_dt': 'nunique'
}
)
.reset_index()
)
profiles.columns = ['user_id', 'source', 'event_count', 'event_unique', 'session_count']
profiles['event_unique'] = profiles['event_unique'].astype('string')
profiles['is_contact_show'] = profiles['event_unique'].apply(lambda x: True if 'contacts_show' in x else False)
profiles = profiles.merge(
session.groupby('user_id')['session_duration'].mean().reset_index()
.rename(columns={'session_duration': 'avg_session'}),
on='user_id',
how='left'
)
profiles.head()
| user_id | source | event_count | event_unique | session_count | is_contact_show | avg_session | |
|---|---|---|---|---|---|---|---|
| 0 | 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 | other | 35 | ['tips_show' 'map'] | 4 | False | 11.475000 |
| 1 | 00157779-810c-4498-9e05-a1e9e3cedf93 | yandex | 71 | ['search' 'photos_show' 'favorites_add' 'conta... | 6 | True | 32.683333 |
| 2 | 00463033-5717-4bf1-91b4-09183923b9df | yandex | 10 | ['photos_show'] | 1 | False | 24.700000 |
| 3 | 004690c3-5a84-4bb7-a8af-e0c8f8fca64e | 32 | ['search' 'map' 'tips_show' 'advert_open'] | 6 | False | 215.583333 | |
| 4 | 00551e79-152e-4441-9cf7-565d7eb04090 | yandex | 8 | ['contacts_show' 'contacts_call' 'search' 'pho... | 3 | True | 3.100000 |
Посчитаем DAU и WAU. Сгруппируем данные по дате и неделе, посчитаем количество уникальных пользователей по столбцу user_id и найдём среднее
dau_total = (
session.groupby('session_dt').agg({'user_id': 'nunique'}).reset_index()
)
wau_total = (
session.groupby(['session_week']).agg({'user_id': 'nunique'}).reset_index()
)
wau_total['session_week'] = wau_total['session_week'].astype('string')
fig = px.bar(dau_total, x='session_dt', y='user_id', text='user_id')
fig.update_layout(title='Количество уникальных пользователей в день',
xaxis_title='день',
yaxis_title='количество пользователей')
fig.update_xaxes(tickangle=45)
fig.show()
print('среднее количество пользователей в день:', (dau_total['user_id'].mean()).round())
среднее количество пользователей в день: 279.0
fig = px.bar(wau_total, x='session_week', y='user_id', text='user_id')
fig.update_layout(title='Количество уникальных пользователей в неделю',
xaxis_title='неделя',
yaxis_title='количество пользователей')
fig.update_xaxes(tickangle=45)
fig.show()
print('среднее количество пользователей в неделю:', (wau_total['user_id'].mean()).round())
среднее количество пользователей в неделю: 1382.0
Найдем метрику Sticky factor отражает регулярность использования приложения и для недельной аудитории рассчитывается как DAU/WAU
print((dau_total['user_id'].mean() / wau_total['user_id'].mean() * 100).round(2))
20.19
Определим среднее число сессий на пользователя в месяц и неделю
В нашем распоряжении данные с 2019-10-07 по 2019-11-03 (4 недели), поэтому среднее количество сессий на человека в месяц у нас:
profiles['session_count'].describe().reset_index()
| index | session_count | |
|---|---|---|
| 0 | count | 4293.000000 |
| 1 | mean | 1.820871 |
| 2 | std | 1.762537 |
| 3 | min | 1.000000 |
| 4 | 25% | 1.000000 |
| 5 | 50% | 1.000000 |
| 6 | 75% | 2.000000 |
| 7 | max | 25.000000 |
Среднее число сессий на человека в неделю
sessions_per_user = session.groupby('session_week').agg(
{'user_id': ['count', 'nunique']}
)
sessions_per_user.columns = ['n_sessions', 'n_users']
# делим число сессий на количество пользователей
sessions_per_user['sessions_per_user'] = (
sessions_per_user['n_sessions'] / sessions_per_user['n_users']
)
sessions_per_user
| n_sessions | n_users | sessions_per_user | |
|---|---|---|---|
| session_week | |||
| 41 | 1478 | 1130 | 1.307965 |
| 42 | 2039 | 1438 | 1.417942 |
| 43 | 2198 | 1546 | 1.421734 |
| 44 | 2102 | 1416 | 1.484463 |
Определим медианную продолжительность сессии MSL
profiles['avg_session'].median()
16.459999999999997
channels = (
logs.groupby('source')['user_id'].nunique().to_frame()
.rename(columns={'user_id': 'user_count'})
.sort_values(by='user_count', ascending=False)
).reset_index()
channels['%'] = round(channels['user_count'] / logs['user_id'].nunique() * 100, 1)
channels
| source | user_count | % | |
|---|---|---|---|
| 0 | yandex | 1934 | 45.1 |
| 1 | other | 1230 | 28.7 |
| 2 | 1129 | 26.3 |
fig = make_subplots(rows=1, cols=2, specs=[[{'type':'bar'}, {'type':'pie'}]])
fig.add_trace(go.Bar(x=channels['user_count'].sort_values(), y=channels.sort_values(by='user_count')['source'], orientation='h',
text=channels['user_count'].sort_values(), marker=dict(color=bam_color[::-1])),
row=1, col=1)
fig.add_trace(go.Pie(labels=channels['source'], values=channels['user_count'], pull = [0.1, 0]),
row=1, col=2)
fig.update_traces(textposition='inside', textinfo='percent+label', row=1, col=2)
fig.update_xaxes(title='Количество пользователей, шт.' ,row=1, col=1)
fig.update_layout(title='Распределение пользователей по источникам привлечения',
width=980,
height=600, showlegend=False)
fig.show()
# определим свою цветовую гамму для графиков проекта
bam_color = ['#3C6255','#9E6F21', '#A4D0A4'] #, '#EAE7B1', '#CCD5AE', '#617A55', '#FEE8B0', '#B8E7E1'] # палитра цветов
pio.templates['bam'] = go.layout.Template(layout_colorway=bam_color)
pio.templates.default = "plotly_white+bam"
Пользователи приходят в приложение из 3(трех) различных источников:
определили сколько всего событий в логе
определили сколько всего пользователей в логе
определили сколько в среднем событий приходится на пользователя
определили период данных, которыми мы располагаем
построили гистограмму по дате и времени. Можно ли быть уверенным, что у нас одинаково полные данные за весь период?
выделили сессии пользователей
создали профили пользователей
user_id - пользователиsource - источник привлеченияevent_count - количество событийevent_unique - список уникальных событийsession_count - количество сессийis_contact_show - маркер целевого событияavg_session - средняя продолжительность сессиирасчитаем метрики пользовательской активности
изучили источники привлечения и определили каналы, из которых пришло больше всего пользователей. Построили таблицу, отражающую количество пользователей и долю для каждого канала привлечения
Пользователи приходят в приложение из 3(трех) различных источников:
contacts_show¶top_script = session[session['script'].str.contains('contacts_show')]['script'].value_counts().reset_index().head(15)
top_script
| index | script | |
|---|---|---|
| 0 | ['tips_show' 'contacts_show'] | 240 |
| 1 | ['contacts_show'] | 116 |
| 2 | ['map' 'tips_show' 'contacts_show'] | 85 |
| 3 | ['photos_show' 'contacts_show'] | 74 |
| 4 | ['contacts_show' 'contacts_call'] | 65 |
| 5 | ['contacts_show' 'tips_show'] | 46 |
| 6 | ['search' 'contacts_show' 'contacts_call'] | 42 |
| 7 | ['search' 'photos_show' 'contacts_show'] | 40 |
| 8 | ['search' 'contacts_show'] | 40 |
| 9 | ['photos_show' 'contacts_show' 'contacts_call'] | 40 |
| 10 | ['search' 'tips_show' 'contacts_show'] | 33 |
| 11 | ['contacts_show' 'photos_show'] | 33 |
| 12 | ['tips_show' 'contacts_show' 'map'] | 31 |
| 13 | ['contacts_show' 'contacts_call' 'photos_show'] | 29 |
| 14 | ['tips_show' 'map' 'contacts_show'] | 26 |
#интересующие нас события, входящие в популярные сценарии
event_list_1 = ['tips_show', 'contacts_show']
event_list_2 = ['map', 'tips_show', 'contacts_show']
event_list_3 = ['photos_show', 'contacts_show']
print('всего пользователей:', logs['user_id'].nunique())
всего пользователей: 4293
#фунция для:
#подсчета количество уникальных пользователей совершивших событие,
#подсчета конверсии 'в шаг',
#визуализация воронки событий
def product_funnel_func(logs, event_list):
#фильтруем датафрейм по пользователям, которые совершают первое событие
users = logs[logs['event_name'] == event_list[0]]['user_id'].unique()
# считаем количество уникальных пользователей совершивших событие из группы отфильтрованных пользователей
events = (
logs[logs['user_id'].isin(users)].groupby('event_name')['user_id'].nunique()
.reset_index()
.rename(columns={'user_id': 'user_count'})
)
events = events.query('event_name in @event_list').set_index('event_name')
events['%'] = round(events['user_count'] / len(users) * 100, 1)
events = events.reindex(event_list).reset_index()
display(events)
# считаем конверсию 'в шаг'
events['conversion, %'] = round(events['user_count'] / events['user_count'].shift(1) * 100, 1)
events.loc[0, 'conversion, %'] = 100
events = events.drop(columns='%')
display(events)
#визуализируем воронку событий
fig = go.Figure()
fig.add_trace(go.Funnel(x=events['conversion, %'], y=events['event_name']))
fig.update_layout(
title={
'text': "Продуктовая воронка",
'y':0.9,
'x':0.5,
'xanchor': 'center',
'yanchor': 'top'},
yaxis_title="событие",
)
fig.show()
Рассмотрим первый (самый популярный сценарий) tips_show -> contacts_show
product_funnel_func(logs, event_list_1)
| event_name | user_count | % | |
|---|---|---|---|
| 0 | tips_show | 2801 | 100.0 |
| 1 | contacts_show | 516 | 18.4 |
| event_name | user_count | conversion, % | |
|---|---|---|---|
| 0 | tips_show | 2801 | 100.0 |
| 1 | contacts_show | 516 | 18.4 |
tips_show -> contacts_show - теряем 81.6% пользователейРассмотрим второй сценарий map -> tips_show -> contacts_show
product_funnel_func(logs, event_list_2)
| event_name | user_count | % | |
|---|---|---|---|
| 0 | map | 1456 | 100.0 |
| 1 | tips_show | 1352 | 92.9 |
| 2 | contacts_show | 289 | 19.8 |
| event_name | user_count | conversion, % | |
|---|---|---|---|
| 0 | map | 1456 | 100.0 |
| 1 | tips_show | 1352 | 92.9 |
| 2 | contacts_show | 289 | 21.4 |
tips_show -> contacts_show - теряем 78.6% пользователейРассмотрим третий популярный сценарий photos_show -> contacts_show
product_funnel_func(logs, event_list_3)
| event_name | user_count | % | |
|---|---|---|---|
| 0 | photos_show | 1095 | 100.0 |
| 1 | contacts_show | 339 | 31.0 |
| event_name | user_count | conversion, % | |
|---|---|---|---|
| 0 | photos_show | 1095 | 100.0 |
| 1 | contacts_show | 339 | 31.0 |
photos_show -> contacts_show - теряем 69% пользователей#выделим логи с интересующими нас действиями
session_search_and_advertopen = (
logs.rename(columns={'event_dt': 'session_dt'})
.query('event_name == "search" or event_name == "advert_open"')
)
#сведем таблицу по пользователям, сессии и событию,
#оставим первое время повторяющихся событий
session_search_and_advertopen = (
session_search_and_advertopen
.pivot_table(index=['user_id', 'session_dt'], columns='event_name', values='event_time', aggfunc='first')
.dropna()
)
#вычислим время между поиском и открытием объявления
#удалим отрицательные значения
session_search_and_advertopen['time_delta'] = (
((session_search_and_advertopen['advert_open'] - session_search_and_advertopen['search']).astype('timedelta64[s]')) / 60
).round(1)
session_search_and_advertopen = (
session_search_and_advertopen[session_search_and_advertopen['time_delta'] > 0]
.reset_index()
)
#добавим признак совершения целевого действия
session_search_and_advertopen = (
session_search_and_advertopen
.merge(profiles[['user_id', 'is_contact_show']], on='user_id', how='left')
)
session_search_and_advertopen.head()
| user_id | session_dt | advert_open | search | time_delta | is_contact_show | |
|---|---|---|---|---|---|---|
| 0 | 00157779-810c-4498-9e05-a1e9e3cedf93 | 2019-10-29 | 2019-10-29 22:10:21.561467 | 2019-10-29 21:18:24.850073 | 51.9 | True |
| 1 | 0164902d-7393-47e1-9d5b-0ec4c0171cdc | 2019-10-28 | 2019-10-28 15:16:57.266458 | 2019-10-28 15:16:28.675257 | 0.5 | False |
| 2 | 017c6afc-965d-4c94-84ee-f0e326998e30 | 2019-10-10 | 2019-10-10 14:29:11.294613 | 2019-10-10 14:28:16.556947 | 0.9 | False |
| 3 | 02e7c193-842b-4995-b67a-8c87ac0f29bb | 2019-10-08 | 2019-10-08 20:43:46.668606 | 2019-10-08 20:41:11.253131 | 2.6 | False |
| 4 | 04adf25e-cb60-4cbd-bedc-ddc1057cde06 | 2019-10-13 | 2019-10-13 15:43:48.511617 | 2019-10-13 15:41:09.088066 | 2.6 | True |
Все сессии, в которых есть поиск и открытие объявления разделены следующим образом
session_search_and_advertopen.groupby('is_contact_show')['session_dt'].count().reset_index()
| is_contact_show | session_dt | |
|---|---|---|
| 0 | False | 223 |
| 1 | True | 85 |
Сгруппируем данные по признаку целевого действия contact_show и определим медианное время между поиском и открытием объявления эти групп
print('Медианное время между поиском и открытием объявления\n'
+ 'у пользователей совершивших целевое событие:',
session_search_and_advertopen.query('is_contact_show == True')['time_delta'].median()
)
print('Медианное время между поиском и открытием объявления\n'
+ 'у пользователей не совершивших целевое событие:',
session_search_and_advertopen.query('is_contact_show == False')['time_delta'].median()
)
Медианное время между поиском и открытием объявления у пользователей совершивших целевое событие: 3.7 Медианное время между поиском и открытием объявления у пользователей не совершивших целевое событие: 1.9
fig = px.box(session_search_and_advertopen, y='time_delta', x='is_contact_show', color='is_contact_show',
points=False
)
fig.update_layout(
title={
'text': "Распределение времени между поиском и открытием объявления у пользователей",
'y':0.95,
'x':0.5,
'xanchor': 'center',
'yanchor': 'top'},
xaxis_title="совершение целевого события (просмотр контакта)",
yaxis_title="время, мин.",
showlegend=False,
)
fig.add_annotation(
text=('медианна: '+str(session_search_and_advertopen.query('is_contact_show == True')['time_delta'].median())),
xref="paper", yref="paper",
x=0.3, y=0.5, showarrow=False)
fig.add_annotation(
text=('медианна: '+str(session_search_and_advertopen.query('is_contact_show == False')['time_delta'].median())),
xref="paper", yref="paper",
x=0.90, y=0.3, showarrow=False)
fig.update_yaxes(range=[0, 60])
fig.show()
медианное время сессии, в которой присутствует поиск и открытие объявления
выделили самые популярные сценарии, включающие целевое событие contacts_show
посчитали, сколько пользователей совершали каждое из этих событий. Отсортировали события по числу пользователей. Посчитали долю пользователей, которые хоть раз совершали событие. По воронке событий посчитали, какая доля пользователей проходит на следующий шаг воронки
tips_show -> contacts_show-- tips_show - 2801 пользователей 100% конверсии в шаг -- contacts_show - 516 пользователей 18.4% конверсии в шаг
tips_show -> contacts_show - теряем 81.6% пользователейmap -> tips_show -> contacts_show-- map - 1456 пользователей 100% конверсии в шаг -- tips_show - 1352 пользователей 92.9% конверсии в шаг -- contacts_show - 289 пользователей 21.4% конверсии в шаг
tips_show -> contacts_show - теряем 78.6% пользователейphotos_show -> contacts_show-- photos_show - 1095 пользователей 100% конверсии в шаг -- contacts_show - 339 пользователей 31.0% конверсии в шаг
photos_show -> contacts_show - теряем 69% пользователейвыделили сессии где есть поиск и открытие объявления и выяснили как различается время между поиском и открытием объявления у пользователей, совершающих и не совершающих целевое действие
contact_show и определили медианное время сессии эти групп-- у пользователей совершивших целевое событие: 3.7 минут -- у пользователей не совершивших целевое событие: 1.9 минут -- время между поиском и открытием объявления у пользователей совершивших целевое событие в 2 раза больше, чем у пользователей, не совершивших целевое событие
tips_show и tips_click, другие — только tips_show. Проверить гипотезу: конверсия в просмотры контактов различается у этих двух групп¶*Сформируем гипотезы:*
H_0: Конверсия в просмотры контактов пользователей, совершающих действия tips_show и tips_click = Конверсии в просмотры контактов пользователей, совершающих только tips_show
H_a: Конверсия в просмотры контактов пользователей, совершающих действия tips_show и tips_click ≠ Конверсии в просмотры контактов пользователей, совершающих только tips_show
alpha = 0.05
Выделим две группы пользователей
tips_show, tips_clicktips_show без tips_clickuser_id_group_A = (
profiles[(profiles['event_unique'].str.contains('tips_show'))
& (profiles['event_unique'].str.contains('tips_click'))
][['user_id']].reset_index(drop=True)
)
user_id_group_A['group'] = 'A'
print('всего пользователей в группе А:', len(user_id_group_A))
user_id_group_A.head()
всего пользователей в группе А: 297
| user_id | group | |
|---|---|---|
| 0 | 01147bf8-cd48-49c0-a5af-3f6eb45f8262 | A |
| 1 | 01b4ca51-930d-4518-aa09-8a8c35e1d9cc | A |
| 2 | 02e7c193-842b-4995-b67a-8c87ac0f29bb | A |
| 3 | 04ee0c31-3c77-49f8-81d2-bbfe9fe2cde8 | A |
| 4 | 0656a1d1-9032-43ae-b936-11e41526eeff | A |
user_id_group_B = (
profiles[(profiles['event_unique'].str.contains('tips_show'))
& ~(profiles['event_unique'].str.contains('tips_click'))
][['user_id']].reset_index(drop=True)
)
user_id_group_B['group'] = 'B'
print('всего пользователей в группе B:', len(user_id_group_B))
user_id_group_B.head()
всего пользователей в группе B: 2504
| user_id | group | |
|---|---|---|
| 0 | 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 | B |
| 1 | 004690c3-5a84-4bb7-a8af-e0c8f8fca64e | B |
| 2 | 00554293-7e00-4122-b898-4e892c4a7c53 | B |
| 3 | 005fbea5-2678-406f-88a6-fbe9787e2268 | B |
| 4 | 007d031d-5018-4e02-b7ee-72a30609173f | B |
Проверим нет ли пересекающихся пользователей между двумя группами
user_id_group_A[user_id_group_A['user_id'].isin(user_id_group_B['user_id'])]
| user_id | group |
|---|
Пересекающихся пользователей нет
Объединим две таблицы в одну
user_id_group_A_B = pd.concat([user_id_group_A, user_id_group_B], axis=0, ignore_index=True)
Создадим новый файл new_logs, где будут присутствовать только пользователи из группы А и группы B
new_logs = logs.merge(user_id_group_A_B, on='user_id')
new_logs.head()
| event_time | event_name | user_id | source | event_dt | group | |
|---|---|---|---|---|---|---|
| 0 | 2019-10-07 13:39:45.989359 | tips_show | 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 | other | 2019-10-07 | B |
| 1 | 2019-10-07 13:40:31.052909 | tips_show | 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 | other | 2019-10-07 | B |
| 2 | 2019-10-07 13:41:05.722489 | tips_show | 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 | other | 2019-10-07 | B |
| 3 | 2019-10-07 13:43:20.735461 | tips_show | 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 | other | 2019-10-07 | B |
| 4 | 2019-10-07 13:45:30.917502 | tips_show | 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 | other | 2019-10-07 | B |
Посчитаем конверсию этих двух групп
Выберем самое популярное событие. Посчитаем число пользователей, совершивших это событие в каждой из контрольных групп. Посчитаем долю пользователей, совершивших это событие. Проверим, будет ли отличие между группами статистически достоверным. Проделаем то же самое для всех других событий (удобно обернуть проверку в отдельную функцию)
def a_b_stats(data, group_1, group_2, alpha=0.05): # функция для подсчета стат. значимости долей
g_1 = (
data[data['group'] == group_1]
.groupby('event_name')['user_id'].nunique()
.sort_values(ascending=False).reset_index()
)
g_1['all_users'] = data[data['group'] == group_1]['user_id'].nunique()
g_2 = (
data[data['group'] == group_2]
.groupby('event_name')['user_id'].nunique()
.sort_values(ascending=False).reset_index()
)
g_2['all_users'] = data[data['group'] == group_2]['user_id'].nunique()
g_g2_table = g_1.merge(g_2, on='event_name', suffixes=('_' + str(group_1), '_' + str(group_2)))
# пропорция успехов в первой группе
g_g2_table['p1'] = (g_g2_table['user_id_' + str(group_1)] / g_g2_table['all_users_' + str(group_1)]).round(3)
# пропорция успехов во второй группе
g_g2_table['p2'] = (g_g2_table['user_id_' + str(group_2)] / g_g2_table['all_users_' + str(group_2)]).round(3)
# пропорция успехов в комбинированном датасете
g_g2_table['p_combined'] = (
(g_g2_table['user_id_' + str(group_1)] + g_g2_table['user_id_' + str(group_2)])
/ (g_g2_table['all_users_' + str(group_1)] + g_g2_table['all_users_' + str(group_2)])
).round(3)
# разница пропорций в датасетах
g_g2_table['difference'] = g_g2_table['p1'] - g_g2_table['p2']
# считаем статистику в ст.отклонениях стандартного нормального распределения
g_g2_table['z_value'] = (
g_g2_table['difference']
/ np.sqrt(
g_g2_table['p_combined']
* (1 - g_g2_table['p_combined'])
* (1 / g_g2_table['all_users_' + str(group_1)] + 1 / g_g2_table['all_users_' + str(group_2)])
)
).round(3)
distr = st.norm(0, 1) # задаем стандартное нормальное распределение (среднее 0, ст.отклонение 1)
# Если бы пропорции были равны, разница между ними была бы равна нулю
# Посчитаем, как далеко статистика уехала от нуля
# Какова вероятность получить такое отличие или больше?
# Так как распределение статистики нормальное, вызовем метод cdf()
# Саму статистику возьмём по модулю методом abs() — чтобы получить правильный результат независимо от её знака
# Это возможно, потому что тест двусторонний. По этой же причине удваиваем результат
g_g2_table['p_value'] = ((1 - distr.cdf(abs(g_g2_table['z_value']))) * 2).round(3)
g_g2_table['result'] = g_g2_table['p_value'].map(
lambda x: 'есть значимая разница между долями' if x < alpha else 'нет значимой разницы между долями'
)
return g_g2_table
Проверим, будет ли отличие между группами A и B статистически значимым
a_b_stats(new_logs, 'A', 'B').query('event_name == "contacts_show"').reset_index(drop=True)
| event_name | user_id_A | all_users_A | user_id_B | all_users_B | p1 | p2 | p_combined | difference | z_value | p_value | result | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | contacts_show | 91 | 297 | 425 | 2504 | 0.306 | 0.17 | 0.184 | 0.136 | 5.719 | 0.0 | есть значимая разница между долями |
Конверсия в просмотры контактов пользователей, совершающих действия tips_show и tips_click = Конверсии в просмотры контактов пользователей, совершающих только tips_show30.6%17%1.8 раза выше группы В*Сформируем гипотезы:*
H_0: Средняя продолжительность сессии пользователей совершающие целевое действие contacts_show = Средней продолжительность сессии пользователей не совершающих целевое действие contacts_show
H_a: Средняя продолжительность сессии пользователей совершающие целевое действие contacts_show ≠ Средней продолжительность сессии пользователей не совершающих целевое действие contacts_show
alpha = 0.05
profiles.head()
| user_id | source | event_count | event_unique | session_count | is_contact_show | avg_session | |
|---|---|---|---|---|---|---|---|
| 0 | 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 | other | 35 | ['tips_show' 'map'] | 4 | False | 11.475000 |
| 1 | 00157779-810c-4498-9e05-a1e9e3cedf93 | yandex | 71 | ['search' 'photos_show' 'favorites_add' 'conta... | 6 | True | 32.683333 |
| 2 | 00463033-5717-4bf1-91b4-09183923b9df | yandex | 10 | ['photos_show'] | 1 | False | 24.700000 |
| 3 | 004690c3-5a84-4bb7-a8af-e0c8f8fca64e | 32 | ['search' 'map' 'tips_show' 'advert_open'] | 6 | False | 215.583333 | |
| 4 | 00551e79-152e-4441-9cf7-565d7eb04090 | yandex | 8 | ['contacts_show' 'contacts_call' 'search' 'pho... | 3 | True | 3.100000 |
Посчитаем 95-й и 99-й перцентили средней сессии пользователей. Выберем границу для определения аномальных значений
print(np.percentile(profiles['avg_session'], [95, 99]))
[287.79 654.912]
Удалим пользователей с аномально длинными сессиями и разделим данные на две группы
clear_profiles = profiles[profiles['avg_session'] < (int(np.percentile(profiles['avg_session'], [95])))]
group_contact_show = clear_profiles[clear_profiles['is_contact_show'] == True]['avg_session']
group_contact_NO_show = clear_profiles[clear_profiles['is_contact_show'] == False]['avg_session']
Применим статистический критерий Манна-Уитни к полученным выборкам
print('p-value: {0:.3f}'.format(st.mannwhitneyu(group_contact_show, group_contact_NO_show)[1]))
print('относительный прирост: {0:.3f}'.format(group_contact_show.mean()/group_contact_NO_show.mean()-1))
p-value: 0.000 относительный прирост: 0.797
одни пользователи совершают действия tips_show и tips_click, другие — только tips_show. Проверили гипотезу: конверсия в просмотры контактов различается у этих двух групп
tips_show и tips_click = Конверсии в просмотры контактов пользователей, совершающих только tips_showtips_show и tips_click ≠ Конверсии в просмотры контактов пользователей, совершающих только tips_showtips_show, tips_click- всего пользователей в группе А: 297tips_show без tips_click - всего пользователей в группе B: 2504new_logs, где присутствовать только пользователи из группы А и группы BКонверсия в просмотры контактов пользователей, совершающих действия tips_show и tips_click = Конверсии в просмотры контактов пользователей, совершающих только tips_show30.6%17%1.8 раза выше группы Всредняя продолжительность сессии пользователей совершающие целевое действие отличается от средней сессии пользователей не совершивших целевое действие
contacts_show = Средней продолжительность сессии пользователей не совершающих целевое действие contacts_showcontacts_show ≠ Средней продолжительность сессии пользователей не совершающих целевое действие contacts_showОписание проекта
Отделу аналитики приложения "Ненужные вещи" поступила задача от продукт-менеджера. Необходимо понять аудиторию приложения, сегментировать пользователей, для этого нам предстоит пронализировать поведение пользователей,- мы должны понять аудиторию, понять сценарии использования приложения
Цель исследования
В наличии есть следующие данные
mobile_dataset.csv - датасет содержит данные о событиях, совершенных в мобильном приложении "Ненужные вещи". В нем пользователи продают свои ненужные вещи, размещая их на доске объявлений. В датасете содержатся данные пользователей, впервые совершивших действия в приложении после 7 октября 2019 годаmobile_sources.csv - датасет с источниками, с которых пользователь установил приложениеЗадачи исследования
Ход исследования
общая информация по данным
основные метрики пользовательской активности
пользователи приходят в приложение из 3(трех) различных источников:
исследование продуктовой воронки
самые популярные сценарии, включающие целевое событие contacts_show
-- tips_show - 2801 пользователей 100% конверсии в шаг
-- contacts_show - 516 пользователей 18.4% конверсии в шаг
-- наблюдаем большую просадку в этом сценарии между шагами tips_show -> contacts_show - теряем 81.6% пользователей
-- map - 1456 пользователей 100% конверсии в шаг
-- tips_show - 1352 пользователей 92.9% конверсии в шаг
-- contacts_show - 289 пользователей 21.4% конверсии в шаг
-- наблюдаем большую просадку в этом сценарии между шагами tips_show -> contacts_show - теряем 78.6% пользователей
-- photos_show - 1095 пользователей 100% конверсии в шаг
-- contacts_show - 339 пользователей 31.0% конверсии в шаг
-- наблюдаем большую просадку в этом сценарии между шагами photos_show -> contacts_show - теряем 69% пользователей
статистическое исследование
сравнение конверсии группы пользователей совершающих действия tips_show и tips_click и совершающий только tips_show в просмотры контактов
стат исследование показало, что конверсия отличается
конверсия группы пользователей совершающих действия tips_show и tips_click в просмотры контактов - 30.6%
конверсия группы пользователей совершающих только tips_show в просмотры контактов - 17%
конверсия группы А в 1.8 раза выше группы В
сравнение средней продолжительности сессии пользователей совершающие просмотр контактов и не совершивших просмотр контактов
стат исследование показало, что среднее время сессии групп пользователей отличается
относительное увеличение средней сессии группы, которая просматривает контакты равен 79.7%
исследования показывают, что пользователи просматривающие контакты тратят намного больше времени между поиском и открытием объявления, так и среднее время сессии таких пользователей выше
это может говорить о каких-то проблемах при использовании целевого дейтсвия - просмотр контактов
рекомендуется дополнительно проанализировать технические аспекты этого действия
большие потери конверсии при переходе на просмотр контактов, так же свидетельствуют об этом
наблюдаем также большое различие конверсии в просмотр контактов групп пользователей, которые только смотрят рекомендации и тех, кто смотри и переходит по ним
получается есть трудности при переходе в контакты сразу из рекомендательной ленты
рекомендуется повышать конверсию переходов на просмотр контактов
так же изучить страницу просмора контактов, возможно получиться ее оптимизировать,- изменить дизайн, сделать более понятной для пользователя, чтобы выровнить время сессии пользователей, а так же время между поиском и открытием объявления у тех кто смотрит контакты и тех, кто не смотрит